Simple Momentum Strategy - Backtesting¶

In this notebook, we will try to answer a very simple question, and that is, can we generate profit by trading stocks at daily basis based on past day's return? meaning that if the return of a stock in the past day is positive, we buy the stock, and if it's negative, we just don't take any actions.

Strategy Logic

  • At the end of each day, we check the day returns of at least 10 stocks (depending on the availability of data).
  • We buy the stocks with positive returns at the closing price of the session, and ignore the stocks with negative rturns. The opened positions are equally weighted.
  • The positions are kept opened overnight and throughout the next trading session.
  • All opened positions are closed at the end of the next trading session, at the closing price.

Important Note: Opening positions at the end of the previous day's session ensures that positions take advantage of any overnight news, in contrast with opening the positions at the open price of the same day.

Table of Contents¶

  • 1. Data Preparation
    • 1.1. Loading stocks OHLC data
    • 1.2. Loading benchmark OHLC data
    • 1.3. Calculating historical daily returns
    • 1.4. Filtering returns
  • 2. Calculating Strategy Returns
    • 2.1. Generating trading signals
    • 2.2. Calculating strategy returns (per position)
    • 2.3. Subtracting trading fees
    • 2.4. Calculating strategy returns (per day)
  • 3. Strategy Performance
    • 3.1. Key metrics
    • 3.2. Performance visualization
  • 4. Conclusions
In [4]:
import pandas as pd
from datetime import timedelta
import datetime as dt

from utils import calculate_portfolio_measures, plot_portfolio_performance

import warnings
warnings.filterwarnings('ignore')

%load_ext autoreload
%autoreload 2

1. Data Preparation¶

We will use the current constituents of the EGX30 index as our universe of stocks for backtesting our strategy, and we will go back in time and start from the oldest day possible that satisfies the 10 stocks rule.

Using the OHLC data downloaded from TradingView, we can start the backtest as from May-2001, which is the first date at which we have historical data for at least 10 stocks of our stocks universe. As time goes by, the number of stocks traded every day increases as more data become available.

1.1. Loading stocks OHLC data¶

We organize the stocks OHLC data in a dataframe with a multi-index of the asset names and dates, this way, we get to batch process all stocks at once for the purposes precalulating indicators and filteration.

In [10]:
assets = [
    'ADIB','ABUK','ALCN','AMOC','BTFH','COMI','SUGR','EFIH','EAST','EFID','HRHO',
    'EKHO','PHAR','SWDY','ESRS','FWRY','GBCO','ISPH','JUFO','MASR','HELI','MFPC',
    'ORHD','ORWE','PHDC','CCAP','SKPC','TMGH','ETEL'
]
data = pd.DataFrame()
for asset in assets:
    asset_data = pd.read_csv(f'data/daily/ohlc/stocks/{asset}.csv')
    asset_data['time'] = pd.to_datetime(asset_data.time)
    asset_data['asset'] = [asset,]*asset_data.shape[0]
    asset_data.set_index(['asset', 'time'], inplace=True)
    asset_data.index.names = ['asset', 'date']
    data = pd.concat([data, asset_data])
data.sort_index(inplace=True)
data
Out[10]:
open high low close
asset date
ABUK 2001-05-15 1.026908 1.036363 0.990545 1.030908
2001-05-16 0.990908 0.993090 0.981817 0.988363
2001-05-17 0.981817 0.981817 0.963635 0.971999
2001-05-20 0.963635 0.963635 0.956363 0.961090
2001-05-21 0.958181 0.958181 0.945454 0.947999
... ... ... ... ... ...
TMGH 2024-07-09 60.610001 62.200001 60.500000 61.990002
2024-07-10 61.990002 62.599998 60.700001 61.000000
2024-07-14 61.000000 61.470001 58.279999 58.599998
2024-07-15 58.599998 59.580002 58.320000 58.799999
2024-07-16 58.800000 59.190000 57.730000 58.030000

121607 rows × 4 columns

1.2. Loading benchmark OHLC data¶

We will be using EGX30 index as benchmark for our strategy. The data is downloaded from TradingView.

In [13]:
egx30_index = pd.read_csv(f'data/daily/ohlc/indices/EGX30.csv')
egx30_index['time'] = pd.to_datetime(egx30_index.time)
egx30_index.set_index(['time'], inplace=True)
egx30_index.index.names = ['date']
egx30_index
Out[13]:
open high low close
date
1998-01-01 1000.000000 1000.250000 1000.000000 1000.250000
1998-01-04 1000.250000 1000.250000 998.390015 998.390015
1998-01-05 998.390015 998.390015 996.250000 996.250000
1998-01-06 996.250000 996.250000 992.869995 992.869995
1998-01-07 992.869995 992.869995 989.349976 989.349976
... ... ... ... ...
2024-07-09 28411.400000 28519.801000 28258.900000 28466.199000
2024-07-10 28487.301000 28702.000000 28251.500000 28288.000000
2024-07-14 28492.301000 28531.801000 27997.801000 28042.100000
2024-07-15 28043.301000 28199.900000 27917.500000 27952.000000
2024-07-16 27952.700000 28095.200000 27785.700000 27828.900000

6471 rows × 4 columns

1.3. Calculating historical daily returns¶

We now proceed to calculate the historical daily returns for the stocks and benchmark, utilizing the following formula:

$$ r_i(t) = \frac{p_i(t) - p_i(t-1)}{p_i(t-1)} $$

where:

  • $r_i(t)$ is the return of stock $i$ at time $t$
  • $p_i(t)$ is the price of stock $i$ at time $t$ (selling price)
  • $p_i(t-1)$ is the price of stock $i$ at time $t-1$ (purchase price)

This is consitstent with the strategy logic mentioned before that we open positions at the close price of the previous day $t-1$. We could have used the open price at time $t$ as the purchase price, but this would ignore the effect of ov.ernight news

In [16]:
rets = data.sort_index().close.groupby(level=0).pct_change().dropna()
rets
Out[16]:
asset  date      
ABUK   2001-05-16   -0.041270
       2001-05-17   -0.016556
       2001-05-20   -0.011223
       2001-05-21   -0.013621
       2001-05-22   -0.002685
                       ...   
TMGH   2024-07-09    0.022769
       2024-07-10   -0.015970
       2024-07-14   -0.039344
       2024-07-15    0.003413
       2024-07-16   -0.013095
Name: close, Length: 121578, dtype: float64
In [17]:
egx30_index_returns = egx30_index.close.pct_change().dropna()
egx30_index_returns
Out[17]:
date
1998-01-04   -0.001860
1998-01-05   -0.002143
1998-01-06   -0.003393
1998-01-07   -0.003545
1998-01-08   -0.005023
                ...   
2024-07-09    0.001911
2024-07-10   -0.006260
2024-07-14   -0.008693
2024-07-15   -0.003213
2024-07-16   -0.004404
Name: close, Length: 6470, dtype: float64

1.4. Filtering returns¶

In order to filter our returns from misleading data, we will apply the following filters:

  • Continuity filter: Only daily returns that are calculated using consecutive business days are kept.
  • Enough stocks filter: Only days with returns data for at least 10 stocks are considered.
In [20]:
## Select daily returns only (removes any return at a given day if it was calculated with respect to a previous close price before more than 3 days)
selected_indices = []
for idx in data.index:
    one_day_before_available = (idx[0], str((idx[1]+timedelta(-1)).date())) in data.index
    two_day_before_available = (idx[0], str((idx[1]+timedelta(-2)).date())) in data.index
    three_day_before_available = (idx[0], str((idx[1]+timedelta(-3)).date())) in data.index
    if one_day_before_available or two_day_before_available or three_day_before_available:
        selected_indices.append(idx)
rets = rets.loc[selected_indices]

# Set any return of less than -10% to -10%
# rets.loc[rets<-0.1] = -0.1

## Set any return of more than 30% to 30%
# rets.loc[rets>0.3] = 0.3

## Remove any day with less than 10 positions
n_assets_per_day = rets.groupby(level=1).count()
days_with_enough_assets = n_assets_per_day.loc[n_assets_per_day>=10].index
rets = rets.loc[(slice(None),days_with_enough_assets)]

rets
Out[20]:
asset  date      
ABUK   2001-05-23    0.029615
       2001-05-24    0.008592
       2001-05-29   -0.000370
       2001-05-31    0.015920
       2001-06-05   -0.003690
                       ...   
TMGH   2024-07-08   -0.009802
       2024-07-09    0.022769
       2024-07-10   -0.015970
       2024-07-15    0.003413
       2024-07-16   -0.013095
Name: close, Length: 115333, dtype: float64

2. Calculating Strategy Returns¶

Now that we have our returns calculated and filtered, we can proceed to calculating strategy returns.

2.1. Generating trading signals¶

First, we will generate the trading signals used to open positions using returns of the past business day.

$$ \text{signal}_i(t) = \begin{cases} 1 & \text{if } r_i(t-1) > 0 \\ 0 & \text{otherwise} \end{cases} $$

where:

  • $signal_i(t)$ is the signal used for stock $i$ at time $t$
  • $r_i(t-1)$ is the return of stock $i$ at time $t-1$
In [25]:
## Calculate lagged returns
# First, we will shift the returns one business day forward, this will make returns of time t-1 available at time t.
egypt_bday = pd.tseries.offsets.CustomBusinessDay(weekmask='Sun Mon Tue Wed Thu')
shifted_date_index = rets.index.get_level_values('date') + egypt_bday
tuples = []
for asset, date in zip(rets.index.get_level_values('asset'), shifted_date_index):
    tuples.append((asset, date))
shifted_multi_index = pd.MultiIndex.from_tuples(tuples)
lagged_rets = rets.copy()
lagged_rets.index = shifted_multi_index

# now we generate signals by setting positive returns to 1 and negative returns to 0
signals = (lagged_rets > 0).astype(int)
signals
Out[25]:
ABUK  2001-05-24    1
      2001-05-27    1
      2001-05-30    0
      2001-06-03    1
      2001-06-06    0
                   ..
TMGH  2024-07-09    0
      2024-07-10    1
      2024-07-11    0
      2024-07-16    1
      2024-07-17    0
Name: close, Length: 115333, dtype: int32

2.2. Calculating strategy returns (per position)¶

Now, we calculate the returns of our strategy at stocks level by multiplying the signals and stocks returns:

$$ r_{strategy,i}(t) = {signal_i(t)}*{r_i(t)} $$

where:

  • $r_{strategy,i}(t)$ is the return of stock $i$ at time $t$ if it was bought by the strategy.
  • $signal_i(t)$ is the signal used for stock $i$ at time $t$
  • $r_i(t-1)$ is the return of stock $i$ at time $t-1$

Then we drop all position with a return of 0 (i.e. the positions that weren't opened), which resulted from multiplication by 0 in the signals. This will leave us with returns of position we opened only.

In [28]:
## Calculate strategy returns
mutual_index = signals.index.intersection(rets.index)
strategy_stocks_rets = (signals.loc[mutual_index] * rets.loc[mutual_index]).dropna()
strategy_stocks_rets = strategy_stocks_rets.loc[strategy_stocks_rets!=0]

2.3. Subtracting trading fees¶

Before aggrigating returns for each day, we need to account for trading fees at both opening (buy) and closing (sell) transactions. The following table shows the transactions fees for each EGX transaction, disregarding brokerage fees, which we will ignore under the assumption that this strategy shall be implemented by licensed brokerage firm, with direct access to the exchange.

image.png

This makes the total transaction fees per order equan to 4bps, which we multiply by 2 to account for both opening and closing transactions, then we subtract it from the return of each position.

In [31]:
strategy_rets = strategy_stocks_rets-0.0008 ## Commission = 2 * (1 bps for EGX + 1 bps for MCDR + 1 bps for FRA + 1 bps for Risk Insurance + 0 Brokerage Fees)

2.4. Calculating strategy returns (per day)¶

Now, we aggrigate the returns for each day by calculating the average return of positions every day. This is consistent with the strategy logic where we open equally weighted positions.

$$ r_{\text{strategy}}(t) = \frac{\sum_{i=1}^{N} r_{\text{strategy},i}(t)}{N} $$

where:

  • $r_{strategy}(t)$ is the average return of the strategy at time $t$.
  • $r_{strategy,i}(t)$ is the return of stock $i$ at time $t$ if it was bought by the strategy.
In [34]:
strategy_rets = strategy_rets.groupby(level=1).mean()
strategy_rets
Out[34]:
2001-05-24    0.024517
2001-06-06   -0.005368
2001-06-07   -0.005308
2001-06-10    0.008847
2001-06-11    0.003657
                ...   
2024-07-07    0.003719
2024-07-08   -0.026633
2024-07-09   -0.002026
2024-07-10   -0.017697
2024-07-16    0.004485
Name: close, Length: 4869, dtype: float64

3. Strategy Performance¶

Now that we finally have a return series for our strategy, we can start analyzing the strategy performance and calculate key metrics and visuals.

3.1. Key metrics¶

First, we will look at key financial metrics to compare the performance of our strategy with respect to EGX30 index. The following measures are calculated:

  • Annualized Return
  • Annualized Volatility
  • Maximum Drawdown
  • Sharpe Ratio
  • Win Rate

Notes:

  • The risk-free-rate used to calculate the Sharpe Ratio is the geometric mean of historical interest rates in Egypt. Interest rates data was downloaded from TradingView.
  • Win Rate metric is calculated as:
$$ Win\:Rate = \frac{number\:of\:days\:with\:positive\:returns}{total\:number\:of\:trading\:days} $$
In [71]:
full_dates = pd.date_range(start=strategy_rets.index.min(), end=strategy_rets.index.max(), freq='d')
measures_df = calculate_portfolio_measures(
    pd.DataFrame(
        {
            "Strategy":strategy_rets.reindex(full_dates, fill_value=0),
            "EGX30 Index": egx30_index_returns.loc[strategy_rets.index.min():strategy_rets.index.max()].reindex(full_dates, fill_value=0)
        }
    ),
    risk_free_rate=0.11,
    periods_per_year=365
)
measures_df
Out[71]:
Annualized Return Annualized Volatility Maximum Drawdown Sharpe Ratio Win Rate
Strategy 0.569103 0.266155 -0.566780 1.724947 0.567262
EGX30 Index 0.175776 0.251954 -0.716035 0.261062 0.536941

3.2. Performance visualization¶

To better understand the performance of the strategy compared to the benchmark, we will create the following visuals:

  • Compounded Growth graph
  • Drowdown graph
  • Returns distribution & Significance Analysis
In [81]:
plot_portfolio_performance(
    pd.DataFrame(
        {
            "Strategy":strategy_rets.reindex(full_dates, fill_value=0),
            "EGX30 Index": egx30_index_returns.loc[strategy_rets.index.min():strategy_rets.index.max()].reindex(full_dates, fill_value=0)
        }
    ),
    log_scale_growth=True,
    bins=50
)
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
<Figure size 640x480 with 0 Axes>

4. Conclusions¶

The results show that even a simple signal generated based on past day return can be used to construct a profitable algorithmic trading strategy that significantly outperforms the EGX30 index. This study does not imply that this specific strategy would be profitable, however, it shows the huge potential of algorithmic trading in the egyptian stock market, given that more sophisticated algorithms were used to generate signals, and more appropriate risk management mechanisms were used to manage strategy risk.

In [ ]: